Parking mandates, also known as parking minimums, are zoning regulations that require developers to build a minimum number of parking spaces with new housing and commercial developments. These mandates typically specify a certain number of parking spaces per unit of housing, square footage of retail space, or other metrics depending on the development type.
This analysis examines the potential impact of new legislation passed in Illinois People Over Parking Act on Chicagoland.
According to the updated bill, minimum parking requirements are prohibited for development projects located within:
# Install packages if not already installed
required_packages <- c("tidyverse", "sf", "leaflet", "leaflet.extras",
"data.table", "zip", "httr", "lubridate", "mapview")
new_packages <- required_packages[!required_packages %in% installed.packages()[,"Package"]]
if(length(new_packages)) install.packages(new_packages)
# Load required packages
library(tidyverse)
library(sf)
library(leaflet)
library(leaflet.extras)
library(data.table)
library(zip)
library(httr)
library(lubridate)
library(mapview)
# Disable s2 processing to avoid geometry validation issues
sf_use_s2(FALSE)
## Load Existing Hub Data and Corridor Data
# Load the existing hub processing results (from the original analysis)
# We'll reuse the hub identification logic from the original script
# Function to download and extract GTFS data (reused from main script)
download_and_extract_gtfs <- function(agency_name, zip_link) {
temp_dir <- file.path(tempdir(), agency_name)
if (!dir.exists(temp_dir)) {
dir.create(temp_dir, recursive = TRUE)
}
temp_file <- file.path(temp_dir, paste0(agency_name, "_gtfs.zip"))
cache_dir <- "gtfs_cache"
if (!dir.exists(cache_dir)) {
dir.create(cache_dir)
}
cache_file <- file.path(cache_dir, paste0(agency_name, "_gtfs.zip"))
tryCatch({
options(timeout = 60)
response <- httr::GET(
zip_link,
httr::user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"),
httr::write_disk(temp_file, overwrite = TRUE),
httr::timeout(60)
)
if (httr::status_code(response) != 200) {
stop(paste0("Failed to download with status code: ", httr::status_code(response)))
}
file.copy(temp_file, cache_file, overwrite = TRUE)
}, error = function(e) {
message(paste0("Download failed for ", agency_name, ": ", e$message))
if (file.exists(cache_file)) {
message(paste0("Using cached GTFS data for ", agency_name, " from ", cache_file))
file.copy(cache_file, temp_file, overwrite = TRUE)
} else {
stop(paste0("Could not download GTFS data for ", agency_name, " and no cache available."), call. = FALSE)
}
})
gtfs_files <- unzip(temp_file, exdir = temp_dir)
return(temp_dir)
}
# Function to read and normalize GTFS data
read_normalize_gtfs <- function(agency_name, agency_dir) {
# Read the stops data
stops_file <- file.path(agency_dir, "stops.txt")
if (file.exists(stops_file)) {
stops <- fread(stops_file)
stops[, agency := agency_name]
if (!"location_type" %in% names(stops)) {
stops[, location_type := NA_integer_]
}
if (!"parent_station" %in% names(stops)) {
stops[, parent_station := NA_character_]
}
stops[, stop_id := as.character(stop_id)]
stops[, unique_stop_id := paste0(agency_name, "_", stop_id)]
} else {
stops <- data.table(
stop_id = character(),
stop_name = character(),
stop_lat = numeric(),
stop_lon = numeric(),
location_type = integer(),
parent_station = character(),
agency = character(),
unique_stop_id = character()
)
}
# Read the routes data
routes_file <- file.path(agency_dir, "routes.txt")
if (file.exists(routes_file)) {
routes <- fread(routes_file)
routes[, agency := agency_name]
routes[, route_id := as.character(route_id)]
routes[, unique_route_id := paste0(agency_name, "_", route_id)]
} else {
routes <- data.table(
route_id = character(),
route_type = integer(),
agency = character(),
unique_route_id = character()
)
}
# Read trips data
trips_file <- file.path(agency_dir, "trips.txt")
if (file.exists(trips_file)) {
trips <- fread(trips_file)
trips[, agency := agency_name]
trips[, trip_id := as.character(trip_id)]
trips[, route_id := as.character(route_id)]
trips[, unique_trip_id := paste0(agency_name, "_", trip_id)]
trips[, unique_route_id := paste0(agency_name, "_", route_id)]
} else {
trips <- data.table(
trip_id = character(),
route_id = character(),
service_id = character(),
agency = character(),
unique_trip_id = character(),
unique_route_id = character()
)
}
# Read stop_times data
stop_times_file <- file.path(agency_dir, "stop_times.txt")
if (file.exists(stop_times_file)) {
stop_times <- fread(stop_times_file)
stop_times[, agency := agency_name]
stop_times[, trip_id := as.character(trip_id)]
stop_times[, stop_id := as.character(stop_id)]
stop_times[, unique_trip_id := paste0(agency_name, "_", trip_id)]
stop_times[, unique_stop_id := paste0(agency_name, "_", stop_id)]
} else {
stop_times <- data.table(
trip_id = character(),
stop_id = character(),
arrival_time = character(),
departure_time = character(),
stop_sequence = integer(),
agency = character(),
unique_trip_id = character(),
unique_stop_id = character()
)
}
# Read calendar data
calendar_file <- file.path(agency_dir, "calendar.txt")
if (file.exists(calendar_file)) {
calendar <- fread(calendar_file)
calendar[, agency := agency_name]
} else {
calendar <- data.table(
service_id = character(),
monday = integer(),
tuesday = integer(),
wednesday = integer(),
thursday = integer(),
friday = integer(),
saturday = integer(),
sunday = integer(),
start_date = integer(),
end_date = integer(),
agency = character()
)
}
return(list(
stops = stops,
routes = routes,
trips = trips,
stop_times = stop_times,
calendar = calendar
))
}
## Download and Process GTFS Data for Hubs
# Download and extract GTFS data for all three agencies
cta_dir <- download_and_extract_gtfs("cta", "https://www.transitchicago.com/downloads/sch_data/google_transit.zip")
pace_dir <- download_and_extract_gtfs("pace", "https://www.pacebus.com/sites/default/files/2025-02/GTFS.zip")
metra_dir <- download_and_extract_gtfs("metra", "https://schedules.metrarail.com/gtfs/schedule.zip")
# Read and normalize GTFS data for all three agencies
cta_data <- read_normalize_gtfs("cta", cta_dir)
pace_data <- read_normalize_gtfs("pace", pace_dir)
metra_data <- read_normalize_gtfs("metra", metra_dir)
# Combine data from all agencies
all_stops <- rbindlist(list(cta_data$stops, pace_data$stops, metra_data$stops), fill = TRUE)
all_routes <- rbindlist(list(cta_data$routes, pace_data$routes, metra_data$routes), fill = TRUE)
all_trips <- rbindlist(list(cta_data$trips, pace_data$trips, metra_data$trips), fill = TRUE)
all_stop_times <- rbindlist(list(cta_data$stop_times, pace_data$stop_times, metra_data$stop_times), fill = TRUE)
all_calendar <- rbindlist(list(cta_data$calendar, pace_data$calendar, metra_data$calendar), fill = TRUE)
## Identify Public Transportation Hubs (Original Logic)
# Identify rail transit stations across all agencies
rail_routes <- all_routes[route_type %in% c(1, 2)]
# For CTA, use parent_station or location_type to identify stations
cta_rail_stops <- all_stops[
agency == "cta" &
((!is.na(parent_station) & parent_station != "") |
(!is.na(location_type) & location_type == 1))
]
# For Metra, all stops are rail stations, but filter out Wisconsin stations
metra_rail_stops <- all_stops[agency == "metra" & stop_lat <= 42.5]
# Combine all rail stations
rail_stops <- rbindlist(list(cta_rail_stops, metra_rail_stops), fill = TRUE)
# Create a spatial object for rail stations
rail_stations_sf <- st_as_sf(rail_stops, coords = c("stop_lon", "stop_lat"), crs = 4326)
# Process bus hubs using the strict frequency criterion
weekday_service <- all_calendar[
monday == 1 & tuesday == 1 & wednesday == 1 & thursday == 1 & friday == 1,
.(service_id, agency)
]
weekday_trips <- merge(all_trips, weekday_service, by = c("service_id", "agency"))
# Define peak hours
morning_peak_start <- as.ITime("07:00:00")
morning_peak_end <- as.ITime("09:00:00")
evening_peak_start <- as.ITime("16:00:00")
evening_peak_end <- as.ITime("18:00:00")
# Process stop times for peak hours
all_stop_times[, arrival_time_hhmmss := substr(arrival_time, 1, 8)]
all_stop_times[, arrival_time_obj := as.ITime(arrival_time_hhmmss)]
peak_stop_times <- all_stop_times[
(arrival_time_obj >= morning_peak_start & arrival_time_obj <= morning_peak_end) |
(arrival_time_obj >= evening_peak_start & arrival_time_obj <= evening_peak_end)
]
# Join with trips to get route information
peak_stop_times <- merge(
peak_stop_times,
weekday_trips[, .(unique_trip_id, unique_route_id, agency)],
by = c("unique_trip_id", "agency")
)
# Calculate headways for frequency analysis
setorder(peak_stop_times, unique_stop_id, unique_route_id, arrival_time_obj)
peak_stop_times[, time_diff := c(NA, diff(as.numeric(arrival_time_obj))),
by = .(unique_stop_id, unique_route_id)]
peak_stop_times[, headway_minutes := time_diff / 60]
# Filter out unreasonable headways
peak_stop_times <- peak_stop_times[!is.na(headway_minutes) & headway_minutes <= 60]
# Calculate median headway for each route at each stop
route_headways <- peak_stop_times[, .(
median_headway = median(headway_minutes, na.rm = TRUE),
num_observations = .N
), by = .(unique_stop_id, unique_route_id, agency)]
# Filter to routes with sufficient observations
route_headways <- route_headways[num_observations >= 3]
# Identify routes that meet the 15-minute frequency criterion
route_headways[, meets_frequency := median_headway <= 15]
# For each stop with multiple routes, check if all routes meet the frequency criterion
stop_route_counts <- route_headways[, .(
total_routes = .N,
qualifying_routes = sum(meets_frequency),
all_routes_qualify = all(meets_frequency)
), by = .(unique_stop_id, agency)]
# Filter to stops with 2+ routes where all routes meet frequency criterion
qualifying_stops <- stop_route_counts[total_routes >= 2 & all_routes_qualify == TRUE]
# Get bus stops (not rail stations)
bus_stops <- all_stops[
(agency == "cta" & (is.na(location_type) | location_type == 0) &
!(unique_stop_id %in% rail_stops$unique_stop_id)) |
(agency == "pace")
]
# Get the full stop information for qualifying bus hubs
qualifying_bus_hubs <- merge(
bus_stops,
qualifying_stops[, .(unique_stop_id, agency, total_routes, qualifying_routes)],
by = c("unique_stop_id", "agency")
)
# Create a spatial object for bus hubs
bus_hubs_sf <- st_as_sf(qualifying_bus_hubs, coords = c("stop_lon", "stop_lat"), crs = 4326)
# Ensure both spatial objects have the same columns before combining
rail_cols <- names(rail_stations_sf)
bus_cols <- names(bus_hubs_sf)
for (col in setdiff(bus_cols, rail_cols)) {
rail_stations_sf[[col]] <- NA
}
for (col in setdiff(rail_cols, bus_cols)) {
bus_hubs_sf[[col]] <- NA
}
# Add type column to both
rail_stations_sf$type <- "rail"
bus_hubs_sf$type <- "bus_hub"
# Combine all hubs
all_hubs_sf <- rbind(rail_stations_sf, bus_hubs_sf)
# Add agency information
all_hubs_sf$agency_name <- factor(
all_hubs_sf$agency,
levels = c("cta", "pace", "metra"),
labels = c("CTA", "Pace", "Metra")
)
## Load Corridor Data
# Load the corridor processing results
if (file.exists("corridor_results.rds")) {
corridor_results <- readRDS("corridor_results.rds")
message("Loaded corridor results from file")
} else {
# If corridor results don't exist, run the corridor processing
source("add_corridors_to_map.R")
corridor_results <- readRDS("corridor_results.rds")
}
# Extract corridor components
cta_corridors_union <- corridor_results$cta_corridors_union
pace_corridors_union <- corridor_results$pace_corridors_union
all_corridors_union <- corridor_results$all_corridors_union
## Create Buffers for Hubs and Combine with Corridors
# Convert hubs to projected CRS for accurate buffer calculation
all_hubs_projected <- st_transform(all_hubs_sf, 3435)
# Create 1/2 mile buffers around hubs (2640 feet)
half_mile_buffers <- st_buffer(all_hubs_projected, 2640)
# Union all hub buffers to create a single polygon
all_hub_areas <- st_union(half_mile_buffers)
# Convert back to WGS84 for mapping
all_hub_areas_wgs84 <- st_transform(all_hub_areas, 4326)
half_mile_buffers_wgs84 <- st_transform(half_mile_buffers, 4326)
# Create separate hub buffers by agency
cta_hub_buffers <- half_mile_buffers_wgs84[half_mile_buffers_wgs84$agency == "cta", ]
pace_hub_buffers <- half_mile_buffers_wgs84[half_mile_buffers_wgs84$agency == "pace", ]
metra_hub_buffers <- half_mile_buffers_wgs84[half_mile_buffers_wgs84$agency == "metra", ]
# Union hub buffers by agency
cta_hubs_union <- if(nrow(cta_hub_buffers) > 0) st_union(cta_hub_buffers) else st_sfc(crs = 4326)
pace_hubs_union <- if(nrow(pace_hub_buffers) > 0) st_union(pace_hub_buffers) else st_sfc(crs = 4326)
metra_hubs_union <- if(nrow(metra_hub_buffers) > 0) st_union(metra_hub_buffers) else st_sfc(crs = 4326)
# Combine all affected areas (hubs + corridors)
all_affected_areas_combined <- st_union(c(all_hub_areas_wgs84, all_corridors_union))
## Calculate Areas
# Calculate areas in square miles
hub_area_sqft <- st_area(all_hub_areas_wgs84)
hub_area_sqmi <- units::set_units(hub_area_sqft, "mi^2")
corridor_area_sqft <- st_area(all_corridors_union)
corridor_area_sqmi <- units::set_units(corridor_area_sqft, "mi^2")
combined_area_sqft <- st_area(all_affected_areas_combined)
combined_area_sqmi <- units::set_units(combined_area_sqft, "mi^2")
# Total area of the Illinois portion of the Chicago MSA
chicago_il_msa_area_sqmi <- 5323.82
# Calculate percentages
pct_hubs <- as.numeric(hub_area_sqmi) / chicago_il_msa_area_sqmi * 100
pct_corridors <- as.numeric(corridor_area_sqmi) / chicago_il_msa_area_sqmi * 100
pct_combined <- as.numeric(combined_area_sqmi) / chicago_il_msa_area_sqmi * 100
# Count hubs and routes
hub_counts <- table(all_hubs_sf$agency_name)
qualifying_route_counts <- table(corridor_results$route_frequencies[meets_frequency == TRUE, agency])
# Define color palettes
agency_pal <- colorFactor(
palette = c("#009CDE", "#814C9E", "#E31837"), # CTA blue, Pace purple, Metra red
domain = all_hubs_sf$agency_name
)
# Create the interactive map
map <- leaflet() %>%
setView(lng = -87.6079, lat = 41.8917, zoom = 9) %>%
addProviderTiles(providers$CartoDB.Positron) %>%
# Combined affected areas (default visible)
addPolygons(
data = all_affected_areas_combined,
fillColor = "purple",
fillOpacity = 0.25,
weight = 1,
color = "purple",
opacity = 0.7,
group = "All Affected Areas (Hubs + Corridors)"
) %>%
# Hub areas by agency
addPolygons(
data = cta_hubs_union,
fillColor = "#009CDE",
fillOpacity = 0.4,
weight = 1,
color = "#009CDE",
opacity = 0.8,
group = "CTA Hubs (1/2 mile)"
) %>%
addPolygons(
data = pace_hubs_union,
fillColor = "#814C9E",
fillOpacity = 0.4,
weight = 1,
color = "#814C9E",
opacity = 0.8,
group = "Pace Hubs (1/2 mile)"
) %>%
addPolygons(
data = metra_hubs_union,
fillColor = "#E31837",
fillOpacity = 0.4,
weight = 1,
color = "#E31837",
opacity = 0.8,
group = "Metra Hubs (1/2 mile)"
) %>%
# Corridor areas by agency
addPolygons(
data = cta_corridors_union,
fillColor = "#009CDE",
fillOpacity = 0.3,
weight = 1,
color = "#009CDE",
opacity = 0.6,
group = "CTA Corridors (1/8 mile)",
dashArray = "5,5"
) %>%
addPolygons(
data = pace_corridors_union,
fillColor = "#814C9E",
fillOpacity = 0.3,
weight = 1,
color = "#814C9E",
opacity = 0.6,
group = "Pace Corridors (1/8 mile)",
dashArray = "5,5"
) %>%
# Hub points
addCircleMarkers(
data = all_hubs_sf,
radius = 3,
color = ~agency_pal(agency_name),
stroke = FALSE,
fillOpacity = 0.8,
group = "Transit Hub Points",
popup = ~paste0(
"<strong>", stop_name, "</strong><br>",
"Agency: ", agency_name, "<br>",
"Type: ", type, "<br>",
"Stop ID: ", stop_id
)
) %>%
# Layer controls
addLayersControl(
baseGroups = c("CartoDB Positron"),
overlayGroups = c(
"All Affected Areas (Hubs + Corridors)",
"CTA Hubs (1/2 mile)",
"Pace Hubs (1/2 mile)",
"Metra Hubs (1/2 mile)",
"CTA Corridors (1/8 mile)",
"Pace Corridors (1/8 mile)",
"Transit Hub Points"
),
options = layersControlOptions(collapsed = FALSE)
) %>%
# Hide individual layers by default, show only combined
hideGroup(c(
"CTA Hubs (1/2 mile)",
"Pace Hubs (1/2 mile)",
"Metra Hubs (1/2 mile)",
"CTA Corridors (1/8 mile)",
"Pace Corridors (1/8 mile)",
"Transit Hub Points"
)) %>%
# Add legend
addLegend(
position = "bottomright",
colors = c("purple", "#009CDE", "#814C9E", "#E31837"),
labels = c("All Areas Affected by People Over Parking Act",
"CTA (Hubs: solid, Corridors: dashed)",
"Pace (Hubs: solid, Corridors: dashed)",
"Metra (Hubs only)"),
opacity = 0.7
) %>%
addFullscreenControl() %>%
addMeasure(
position = "bottomleft",
primaryLengthUnit = "miles",
primaryAreaUnit = "sqmiles",
activeColor = "#3D535D",
completedColor = "#7D4479"
) %>%
addMiniMap(
tiles = providers$CartoDB.Positron,
toggleDisplay = TRUE
)
# Display the map
map